Next.js(Pages Router)と  Hono を使用したストリーミング処理の実装注意点

Next.js(Pages Router)と Hono を使用したストリーミング処理の実装注意点

Clock Icon2024.12.26

はじめに

こんにちは。Honoが好きなコンサル部の神野です。

HonoにはStreaming Helper機能が用意されていて、ストリーミング処理を試したいと思い、この機能を使ってChatGPT風のAIアプリを作ろうと考えました。

https://hono.dev/docs/helpers/streaming

フロント側はNext.js(Pages Router)とHonoを組み合わせている例も存在したので、Next.jsとHonoのStreaming Helper機能を使ってアプリを作ろう!!と思い、作成していましたが画面側への文字表示がストリーミングではなく一括で表示されて、下記gif画像のような現象に遭遇しました。

遭遇した事象

CleanShot 2024-12-26 at 01.06.39

今回はこの現象の解決方法および原因調査方法など共有させていただきます。

いきなり原因・解決方法

  • Next.js(Pages Router)のデフォルト設定では、APIレスポンスがgzip圧縮される
  • 圧縮により、チャンク単位でのストリーミング処理が正しく行われない
  • next.config.tsに個別のAPIに対して圧縮しない設定を追加することで問題が解決

設定例

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // 追加
  async headers() {
    return [
      {
        source: "<任意のAPIパス>",
        headers: [
          {
            key: "Content-Encoding",
            value: "none", // 圧縮を無効化
          },
        ],
      },
    ];
  },
  output: "standalone",
};

次のセクションから実装や何が起きたか、調査方法や解決方法についてブレイクダウンしていきます。

前提

今回使用したフレームワークやライブラリなどのバージョンは以下の通りです。

  • Next.js(Pages Router) 15.1.2
  • Hono 4.6.14
  • @hono/node-server 1.13.7
  • React 19.0.0
  • Node.js 20.16.0

Next.js(Pages Router) + Hono の組み合わせは下記ブログ記事で紹介されていたため、実装の参考にさせていただきました。

https://zenn.dev/eronghii/articles/9e1f0c73001f56

環境構築

下記手順で作成しました。

  1. create-next-appコマンドを実行してNext.jsのテンプレートを作成(今回はPages Routerを使用するため、App RouterはNo)
実行コマンド
npx create-next-app@latest

Would you like to use TypeScript? … Yes
Would you like to use ESLint? … Yes
Would you like to use Tailwind CSS? … Yes
Would you like your code inside a `src/` directory? … Yes
Would you like to use App Router? (recommended) … No
Would you like to use Turbopack for `next dev`? …  Yes
Would you like to customize the import alias (`@/*` by default)? No
  1. 使用するライブラリをインストール
実行コマンド
npm i hono @hono/node-server

実装

ChatGPT風の画面を作成するため、HonoのStreaming Helper機能を使用して以下のようなコードを実装していきました。
api/chatにPOSTすると、こんにちは。これはテストメッセージです。一文字ずつ表示されます。が一文字ずつ返却される作りとなっています。

Hono実装

Route HandlerとしてHonoを活用する実装です。

pages/api/[...route].ts
import { Hono } from "hono";
import { handle } from "@hono/node-server/vercel";
import type { PageConfig } from "next";
import { streamText } from "hono/streaming";

export const config: PageConfig = { api: { bodyParser: false } };

const app = new Hono().basePath("/api");

app.post("/chat", async (c) => {

  return streamText(
    c,
    async (stream) => {
      const messages =
        "こんにちは。これはテストメッセージです。一文字ずつ表示されます。".split(
          ""
        );
      for (const char of messages) {
        await stream.write(char)
        await stream.sleep(100) // 100ms待機
      }
    },
    async (err, stream) => {
      console.error("Streaming error:", err);
      await stream.write("エラーが発生しました。");
    }
  );
});

export default handle(app);

Next.js実装

フロント側のコードは下記の通りです。

index.tsx全体
pages/index.tsx
import { useState } from "react";

export default function Home() {
  const [message, setMessage] = useState("");
  const [chatHistory, setChatHistory] = useState<
    { type: "user" | "assistant"; content: string }[]
  >([]);
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!message.trim() || isLoading) return;

    // ユーザーのメッセージをチャット履歴に追加
    setChatHistory((prev) => [...prev, { type: "user", content: message }]);

    setIsLoading(true);
    // 新しいアシスタントの返信用に空のメッセージを追加
    setChatHistory((prev) => [...prev, { type: "assistant", content: "" }]);

    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({ message }),
      });

      if (!response.ok) {
        throw new Error("Network response was not ok");
      }

      const reader = response.body?.getReader();
      if (!reader) {
        throw new Error("No reader available");
      }

      const decoder = new TextDecoder();

      try {
        while (true) {
          const { done, value } = await reader.read();

          if (done) {
            break;
          }

          const text = decoder.decode(value);
          // チャット履歴の最後のメッセージ(アシスタントの返信)を更新
          setChatHistory((prev) => {
            const newHistory = [...prev];
            const lastMessage = newHistory[newHistory.length - 1];
            if (lastMessage.type === "assistant") {
              lastMessage.content += text;
            }
            return newHistory;
          });
        }
      } finally {
        reader.releaseLock();
      }
    } catch (error) {
      console.error("Error:", error);
      setChatHistory((prev) => {
        const newHistory = [...prev];
        const lastMessage = newHistory[newHistory.length - 1];
        if (lastMessage.type === "assistant") {
          lastMessage.content = "エラーが発生しました。";
        }
        return newHistory;
      });
    } finally {
      setIsLoading(false);
      setMessage(""); // 入力フィールドをクリア
    }
  };

  return (
    <div className="min-h-screen bg-gray-100 p-4">
      {/* ヘッダー */}
      <header className="max-w-4xl mx-auto mb-4">
        <h1 className="text-2xl font-bold text-gray-800">AI Chat Assistant</h1>
      </header>

      <div className="max-w-4xl mx-auto h-[calc(100vh-8rem)] bg-white rounded-xl shadow-lg flex flex-col">
        {/* 初期メッセージ */}
        {chatHistory.length === 0 && (
          <div className="flex-1 flex flex-col items-center justify-center p-4 space-y-4 text-center">
            <h2 className="text-2xl font-semibold text-gray-800">
              AIアシスタントへようこそ!
            </h2>
            <p className="text-gray-600 max-w-md">
              なんでも質問してください。できる限り丁寧にお答えします。
            </p>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-8 w-full max-w-2xl">
              <div className="bg-gray-50 p-4 rounded-lg">
                <h3 className="font-medium text-gray-800 mb-2">例えば...</h3>
                <ul className="text-gray-600 text-sm space-y-2">
                  <li>「今日の天気について教えて」</li>
                  <li>「プログラミングについて質問があります」</li>
                  <li>「おすすめの本を教えて」</li>
                </ul>
              </div>
              <div className="bg-gray-50 p-4 rounded-lg">
                <h3 className="font-medium text-gray-800 mb-2">使い方</h3>
                <ul className="text-gray-600 text-sm space-y-2">
                  <li>• Enterキーで送信</li>
                  <li>• Shift + Enterで改行</li>
                  <li>• 質問は具体的に書くとより良い回答が得られます</li>
                </ul>
              </div>
            </div>
          </div>
        )}

        {/* チャット履歴 */}
        {chatHistory.length > 0 && (
          <div className="flex-1 overflow-y-auto p-4 space-y-4">
            {chatHistory.map((chat, index) => (
              <div
                key={index}
                className={`flex ${
                  chat.type === "user" ? "justify-end" : "justify-start"
                }`}
              >
                <div
                  className={`rounded-lg p-3 max-w-[80%] ${
                    chat.type === "user"
                      ? "bg-blue-500 text-white"
                      : "bg-gray-200 text-gray-800"
                  }`}
                >
                  {chat.content}
                </div>
              </div>
            ))}
          </div>
        )}

        {/* 入力フォーム */}
        <form onSubmit={handleSubmit} className="border-t border-gray-200 p-4">
          <div className="flex space-x-4">
            <textarea
              value={message}
              onChange={(e) => setMessage(e.target.value)}
              onKeyDown={(e) => {
                if (
                  e.key === "Enter" &&
                  !e.shiftKey &&
                  !e.nativeEvent.isComposing
                ) {
                  e.preventDefault();
                  handleSubmit(e);
                }
              }}
              placeholder="メッセージを入力..."
              disabled={isLoading}
              rows={1}
              className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 text-gray-800 resize-none min-h-[44px] max-h-[200px] overflow-y-auto"
              style={{ lineHeight: "1.5" }}
            />
            <button
              type="submit"
              disabled={isLoading}
              className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-400 transition-colors duration-200"
            >
              {isLoading ? "送信中..." : "送信"}
            </button>
          </div>
          <div className="mt-2 text-xs text-gray-500 text-right">
            Shift + Enter で改行
          </div>
        </form>
      </div>
    </div>
  );
}

全体は長いので、ストリーミング処理部分だけ抜粋します。

pages/index.tsx
try {
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ message }),
  });

  if (!response.ok) {
    throw new Error("Network response was not ok");
  }

  const reader = response.body?.getReader();
  if (!reader) {
    throw new Error("No reader available");
  }

  const decoder = new TextDecoder();

  try {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        break;
      }

      const text = decoder.decode(value);
      // チャット履歴の最後のメッセージ(アシスタントの返信)を更新
      setChatHistory((prev) => {
        const newHistory = [...prev];
        const lastMessage = newHistory[newHistory.length - 1];
        if (lastMessage.type === "assistant") {
          lastMessage.content += text;
        }
        return newHistory;
      });
    }
  } finally {
    reader.releaseLock();
  }
} catch (error) {
  console.error("Error:", error);
  setChatHistory((prev) => {
    const newHistory = [...prev];
    const lastMessage = newHistory[newHistory.length - 1];
    if (lastMessage.type === "assistant") {
      lastMessage.content = "エラーが発生しました。";
    }
    return newHistory;
  });
} finally {
  setIsLoading(false);
  setMessage(""); // 入力フィールドをクリア
}
};

要点としては下記となります。

  • response.body.getReader() でストリームを読み取り
  • TextDecoder でバイナリデータをテキストに変換
  • 受信したテキストを逐次的にUIに反映

と言った処理で処理自体に大きく違和感はないかと思います。
この状態でHono側へリクエストを送信し、ストリーミングでデータを受信できるか確認してみました。

発生した問題

実行したところ一文字ずつ画面上には表示されず、一括でサーバーからの文字列が表示される事態となりました・・・

実行結果

CleanShot 2024-12-26 at 01.06.39

原因はHono側かNext.js側なのか、はたまた何なのかを特定したく、まずはHono側のエンドポイントにcurlコマンドを実行して、ストリーミング可能か確認してみました。

実行コマンド
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"test"}' -N -v

CleanShot 2024-12-26 at 01.11.43

あれ、問題なくストリーミングに成功し、一文字ずつ受け取れていますね・・・
レスポンスのヘッダーも問題なさそうに思えます。

レスポンスヘッダー
< HTTP/1.1 200 OK
< content-type: text/plain; charset=UTF-8
< x-content-type-options: nosniff
< transfer-encoding: chunked
< Vary: Accept-Encoding
< Date: Wed, 25 Dec 2024 16:11:47 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5

一旦はHono側は問題ないと思ったので、次にNext.js↔︎Hono間のレスポンスが今のcurlと同じヘッダーになっているか気になり確認してみました。

再度、送信ボタンを押下して開発者ツールのNetworkタブでapi/chatリクエストに対してのレスポンスヘッダーを確認します。先ほどとの違いを見ると、Content-Encodinggzipに指定されています。

圧縮されているためストリーミングデータを1文字ずつ取り出せていないのが原因では・・・?と結論に至りました。

ネットワークタブの画面

CleanShot 2024-12-26 at 01.15.14@2x

解決方法

公式ドキュメントを参照すると、Next.jsはデフォルトで圧縮する仕様みたいで、next.config.tscompressプロパティをfalseにすることでContent-Encodingの設定を無効にできると記載がありました。

https://nextjs.org/docs/pages/api-reference/config/next-config-js/compress

ただ、全体をfalseにするのはパフォーマンス上も好ましくないと思い(公式ドキュメントでも理由がない限りはfalseは推奨しないと言っています)、特定のパス(今回なら/api/chat)だけ変更できないかと思って調べたところheaders関数を作成して、Content-Encodingnoneにすれば一部だけ圧縮の設定を変更できると分かりました。実際に下記のように設定してみました。

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: false,
+ async headers() {
+   return [
+     {
+       // /api/chat に対する設定
+       source: "/api/chat",
+       headers: [
+         {
+           key: "Content-Encoding",
+           value: "none", // 圧縮を無効化
+         },
+       ],
+     },
+   ];
  },
  output: "standalone",

};

export default nextConfig;
参考ドキュメント

https://nextjs.org/docs/pages/api-reference/config/next-config-js/headers

再度実行

設定を変更して再度実行してみます!!

CleanShot 2024-12-26 at 01.24.27

おおお、一文字ずつ表示されるようになりましたね!!!
これでNext.js + HonoでStreaming Helper機能を問題なく使用できることが確認できました!

また、ネットワークタブを見てみるとlocalhostはgzip圧縮されていますが、api/chatへのアクセスは圧縮されていないことも確認してみます。

localhost

Content-Encodinggzipで設定。

CleanShot 2024-12-26 at 01.25.59@2x

/api/chat

Content-Encodingnoneで設定。

CleanShot 2024-12-26 at 01.28.22@2x

意図通りapi/chatのみContent-Encodingの設定が適応されているのが確認できました!

おわりに

Next.jsとHonoのStreaming Helper機能を組み合わせてストリーミング処理を実装する際は、デフォルトの圧縮設定に注意が必要です。
本記事が同様の問題に直面している方の参考になれば幸いです。

最後までお読みいただき、ありがとうございました!

補足:App Routerの場合

今回はPages Routerでの実装時に遭遇した問題と解決方法を主に説明しましたが、App Routerでも同様の実装を試してみました。

App Router版の実装では、
以下の変更点のみで問題なくストリーミング処理が機能しました。

  • ファイル配置を app/api/[...route]/route.ts に変更
  • アダプターを @hono/node-server/vercel から hono/vercel に変更
  • エクスポート方法を export const POST = handle(app) の形式に変更

Honoのコード例

app/api/[...route]/route.ts
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { streamText } from "hono/streaming";

const app = new Hono().basePath("api")

app.post("/chat", async (c) => {

  return streamText(
    c,
    async (stream) => {
      const messages =
        "こんにちは。これはテストメッセージです。一文字ずつ表示されます。".split(
          ""
        );
      for (const char of messages) {
        await stream.write(char)
        await stream.sleep(100) // 100ms待機
      }
    },
    async (err, stream) => { // ここに async を追加
      console.error("Streaming error:Ho", err);
      await stream.write("エラーが発生しました。");
    }
  );
});

export const POST =  handle(app);

実行してみると、next.config.tsの設定変更なしで一文字ずつ表示される動作を確認できました。
レスポンスヘッダーも確認するとContent-Encoding は何も設定されていませんでした。

Next.jsでHonoのStreaming Helper機能を使用する場合は

  • Pages Router → next.config.tsでの圧縮設定の調整が必要
  • App Router → 追加設定なしでストリーミング処理が可能

という違いがあることが分かりました。実装前にこの点を確認しておくと、スムーズに開発を進められそうですね。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.